Scrawl.php

<?php

namespace Tlf;

class Scrawl {



    /** array for get/set */
    public array $stuff = [];

    public array $extensions = [
        'code'=>[],
    ];

    /** absolute path to your documentation dir */
    public ?string $dir_docs = null;

    /** absolute path to the root of your project */
    public ?string $dir_root = null;

    /** absolute path to the documentation source dir */
    public ?string $dir_src = null;

    /** array of relative path to dirs to scan, within your dir_root */
    public ?array $dir_scan = [];

    /** array of handlers for the mdverb extension */
    public array $verb_handlers = [];

    /** absolute path to directories that contain md templates */
    public array $template_dirs = [];

    /** if true, append two spaces to every line so all new lines are parsed as new lines */
    public bool $markdown_preserveNewLines = true;

    /** if true, add an html comment to md docs saying not to edit directly */
    public bool $markdown_prependGenNotice = true;

    /** if true, copies `docs/README.md` to project root `README.md` */
    public bool $readme_copyFromDocs = true;

    /** If true will delete all files in your docs dir before running */
    public bool $deleteExistingDocs = false;

    /**
     * @parma $options `key=>value` array to set properties
     */
    public function __construct(array $options=[]){
        $this->options = $options;
        foreach ($this->options as $k=>$o){
            $k = str_replace('.','_',$k);
            $this->$k = $o;
        }

        $this->template_dirs[] = __DIR__.'/Template/';
    }

    public function get_template(string $name, array $args){

        foreach ($this->template_dirs as $path){
            if (file_exists($file = $path.'/'.$name.'.md.php')){}
            else if (file_exists($file=$path.'/'.$name.'.php')){}
            else continue;
            $out = (function(array $args, string $file) {
                ob_start();
                require($file);
                $out = ob_get_clean();
                return $out;
            })($args, $file);
            return $out;
        }
        
        $this->warn("@template", $msg="Template '$name' does not exist.");
        return $msg;
    }

    public function get(string $group, string $key){
        if (!isset($this->stuff[$group])){
            $this->warn("Group not set", $group);
            return null;
        } else if (!isset($this->stuff[$group][$key])){
            $this->warn("Group.Key not set", "$group.$key");
            return null;
        }
        return $this->stuff[$group][$key];
    }
    public function get_group(string $group){
        if (!isset($this->stuff[$group])){
            $this->warn("Group not set", $group);
            return null;
        }
        return $this->stuff[$group];
    }


    public function set(string $group, string $key, $value){
        $this->stuff[$group][$key] = $value;
    }

    public function parse_str($str, $ext){
        $out = [];
        foreach ($this->extensions['code'][$ext] as $ext){
            $out = $ext->parse_str($str); 
        }
        return $out;
    }

    /**
     * save a file to disk in the documents directory
     */
    public function write_doc(string $rel_path, string $content){
        $content = $this->prepare_md_content($content);
        $rel_path = str_replace('../','/', $rel_path);
        $path = $this->dir_docs.'/'.$rel_path;
        $dir = dirname($path);
        if (!is_dir($dir))mkdir($dir,0755,true);
        if (is_file($path)){
            $this->good('Overwrite',$rel_path);
        } else {
            $this->good('Write',$rel_path);
        }
        file_put_contents($path, $content);
    }

    /**
     * save a file to disk in the root directory
     */
    public function write_file(string $rel_path, string $content){
        $rel_path = str_replace('../','/', $rel_path);
        $path = $this->dir_root.'/'.$rel_path;
        $dir = dirname($path);
        if (!is_dir($dir))mkdir($dir,0755,true);
        if (is_file($path)){
            $this->good('Overwrite',$rel_path);
        } else {
            $this->good('Write',$rel_path);
        }
        file_put_contents($path, $content);
    }

    /**
     * Read a file from disk, from the project root
     */
    public function read_file(string $rel_path){
        return file_get_contents($this->dir_root.'/'.$rel_path);
    }
    /**
     * Read a file from disk, from the project docs dir
     */
    public function read_doc(string $rel_path){
        return file_get_contents($this->dir_docs.'/'.$rel_path);
    }

    /** get a path to a docs file */
    public function doc_path(string $rel_path){
        return $this->dir_docs.'/'.$rel_path;
    }


    /**
     * Output a message to cli (may do logging later, idk)
     */
    public function report(string $msg){
        echo "\n$msg";
    }

    /**
     * Output a message to cli, header highlighted in red
     */
    public function warn($header, $message){
        echo "\033[0;31m$header:\033[0m $message\033[0;31m\033[0m\n";
    }

    /**
     * Output a message to cli, header highlighted in red
     */
    public function good($header, $message){
        echo "\033[0;32m$header:\033[0m $message\033[0;31m\033[0m\n";
    }

    /** apply small fixes to markdown */
    public function prepare_md_content(string $markdown){

        if ($this->markdown_preserveNewLines){
            $markdown  = str_replace("\n","  \n",$markdown);
        }

        if ($this->markdown_prependGenNotice){
            // @TODO give relative path to source file
            $markdown = "<!-- DO NOT EDIT. This file generated from template by Code Scrawl https://tluf.me/php/code-scrawl/ -->  \n".$markdown;
        }
        
        return $markdown;
    }


    public function get_all_docsrc_files(){
        $files = \Tlf\Scrawl\Utility\Main::allFilesFromDir($this->dir_src, '');
        return $files;
    }

    /** 
     * get array of all files in `$scrawl->dir_scan` 
     * @return array or relative paths within `$scrawl->dir_scan` 
     */
    public function get_all_scan_files(): array{
        $all = [];
        foreach ($this->dir_scan as $f){
            $files = \Tlf\Scrawl\Utility\Main::allFilesFromDir($this->dir_root, $f);
            $all = array_merge($all, $files);
        }
        return $all;
    }

    /**
     * Generate api docs for all files
     * (currently only php files)
     */
    public function generate_apis() {
        foreach ($this->get_all_scan_files() as $file){
            $this->generate_api($file);
        }
    }

    /**
     * Generate api doc for a signle file
     * (currently only php files)
     *
     * @param $rel_path relative path inside `$scrawl->dir_root` 
     */
    public function generate_api($rel_path){
        if (strtolower(pathinfo($rel_path,PATHINFO_EXTENSION))!=='php')return;
        $php_ext = new \Tlf\Scrawl\FileExt\Php($this);
        $ast = $php_ext->parse_file($rel_path);

        $path = $ast['path'];
        // $rel_path = substr($path, strlen($scrawl->dir_root));
        // $ast['path'] = $rel_path;

        // $scrawl->set('ast','file.'.$rel_path, $ast);

        $classes = array_merge($ast['class'] ?? [], $ast['namespace']['class'] ?? []);

        if (count($classes)==0)return;
        $doc = "# File ".$rel_path."\n";
        foreach ($classes as $c){
            $markdown = $this->get_template('ast/class', [null,$c,null]);

            $doc .="\n".$markdown;
        }

        $this->write_doc('api/'.$rel_path.'.md', $doc);
    }

    public function get_all_classes(){
        $php_ext = new \Tlf\Scrawl\FileExt\Php($this);
        $files = $this->get_all_scan_files();
        $classes = [];
        foreach ($files as $f){
            if (strtolower(pathinfo($f,PATHINFO_EXTENSION))!=='php')continue;
            $this->report("Generate Ast: ".$f);
            $ast = $php_ext->parse_file($f);
            $new_classes = array_merge($ast['class'] ?? [], $ast['namespace']['class'] ?? []);
            foreach ($new_classes as &$c){
                $c['file'] = $f;
                $classes[$c['fqn']] = $c;
            }
        }

        return $classes;

    }



    /**
     * Execute scrawl in its entirety
     */
    public function run(){
        // 1. process all scan dirs
            // parse into ast,
            // set asts (class) on scrawl
        // 2. Generate api dir
        // 3. Scan docs source dir
        // 4. Process docs source into docs

        if ($this->deleteExistingDocs){
            $del_dir = realpath($this->dir_docs);
            $cwd = realpath(getcwd());
            $len = strlen($cwd);
            if (substr($del_dir,0,$len)===$cwd
                && strlen($cwd)>6
                &&count(explode('/',$cwd))>=4
            ){
                $this->warn("Delete Dir", $del_dir);
                \Tlf\Scrawl\Utility\Main::DANGEROUS_removeNonEmptyDirectory($del_dir);
            }
        }
        $this->report("Generate Asts");
        //////////
        // process php files into 'ast'
        //////////
        $classes = $this->get_all_classes();
        foreach ($classes as $c){
            $this->set('ast', 'class.'.$c['fqn'], $c);
        }
        $this->report("Generate APIs");
        $this->generate_apis();


        //////////
        // process all code files using code extensions
        //////////
        $export_docblock = new \Tlf\Scrawl\FileExt\ExportDocBlock();
        $export_startend = new \Tlf\Scrawl\FileExt\ExportStartEnd();
        // $php_ext = new \Tlf\Scrawl\FileExt\Php();

        $code_files = $this->get_all_scan_files();
        foreach ($code_files as $f){
            $path = $this->dir_root.'/'.$f;
            $file_content = file_get_contents($path);
            // docblock @export()s
            $docblocks = $export_docblock->get_docblocks($file_content);
            $exports = $export_docblock->get_exports($docblocks);
            foreach ($exports as $k=>$e)$this->set('export',$k, $e);
            // @export_start/@export_end()
            $exports = $export_startend->get_exports($file_content);
            foreach ($exports as $k=>$e)$this->set('export',$k, $e);
        }

        //////////
        // process all documentation source files
        //////////
        $src_files = $this->get_all_docsrc_files();

        $mdverb_ext = $this->get_mdverb_ext();

        foreach ($src_files as $sf){
            $path = $this->dir_src.'/'.$sf;
            $content = file_get_contents($path);
            // process mdverbs
            $content = $mdverb_ext->replace_all_verbs($content);
            if (substr($sf,-7)=='.src.md')$sf = substr($sf,0,-7).'.md';
            $this->write_doc($sf, $content);
        }



        $readme_path = $this->doc_path('README.md');
        if ($this->readme_copyFromDocs && file_exists($readme_path)){
            $this->write_file('README.md', $this->read_doc('README.md'));
        }

        $this->good("Finished",'Code Scrawl Ran');
    }

    /**
     * get an array ast from a file
     * Currently only supports php files
     * Also sets the ast to scrawl
     */
    public function get_ast(string $file): ?array {
        $ext = pathinfo($file, PATHINFO_EXTENSION);
        if ($ext!='php'){
            $this->report("File '$file' not .php. Skip ast parse.");
            return null;
        }
        $php_ext = new \Tlf\Scrawl\FileExt\Php($this);
        $ast = $php_ext->parse_file($file);
        // $php_ext->set_ast($ast);
        return $ast;
    }


    /** get the class ast
     * @param $class fully qualified class name
     */
    public function get_class_ast(string $class): ?array{
       $ast = $this->get('ast','class.'.$class); 
       return $ast;
    }


    public function get_mdverb_ext(){
        $mdverb_ext = new \Tlf\Scrawl\Ext\MdVerbs($this);
        $main_verbs_ext = new \Tlf\Scrawl\Ext\MdVerb\MainVerbs($this);
        $main_verbs_ext->setup_handlers($mdverb_ext);

        $ast_ext = new \Tlf\Scrawl\Ext\MdVerb\Ast($this);
        $mdverb_ext->handlers['ast'] = [$ast_ext, 'get_markdown'];
        foreach ($this->verb_handlers as $k=>$v){
            $mdverb_ext->handlers[$k] = $v;
        }
        return $mdverb_ext;
    }

}